Skip to main content

httpx 源码阅读

1. 目录结构

httpx
├── cmd
│   ├── httpx 主程序
├── common
│   ├── customextract 自定义正则提取
│   ├── customheader 自定义请求头
│   ├── customlist
│   ├── customports 自定义端口
│   ├── fileutil 文件处理
│   ├── hashes
│   │   └── jarm JARM指纹相关
│   ├── httputilz http请求处理
│   ├── httpx 探针
│   ├── slice 切片处理
│   └── stringz 字符串处理
├── runner 核心模块
├── scripts
├── static
├── test

2. 运行流程

httpx 是一个快速且多用途的 HTTP 工具包,能够帮助我们获取目标资产的 title、IP 等信息。

github 地址:httpx

​httpx 中的 Options 结构体为扫描器提供了多样的配置选择项,用户通过带参数运行 httpx 即可启动相应的配置。程序根据 Options 的配置情况返回一个 runner 实例,然后调用runner.RunEnumeration() 执行扫描任务。

func main() {
// 解析参数
options := runner.ParseOptions()
......
// 根据options创建runner
httpxRunner, err := runner.New(options)
......
// 启动runner
httpxRunner.RunEnumeration()
httpxRunner.Close()

​ runner 为扫描器的核心模块,执行扫描任务前会根据 Options 的配置情况决定是否开启对应的功能。例如启动时添加了-s 选项,那么扫描器会进入流模式,所有待扫描目标都会传输到一个 channel 中,执行扫描的函数会一直从这个 channel 中取目标进行扫描,从而实现读取目标、扫描目标同步进行,默认情况下需要读取完所有目标后在进入扫描阶段。对于持续输送目标给 httpx 的场景建议开启流模式。整个程序的运行流程如下图所示。

HTTPX源码

func (r *Runner) RunEnumeration() {
......
var streamChan chan string
// 流模式扫描
if r.options.Stream {
var err error
// steamChan是一个chancel,持续传送目标地址
streamChan, err = r.streamInput()
......
} else {
// 先把所有目标读入内存再扫描
r.prepareInput()
......
}

// 默认50线程
wg := sizedwaitgroup.New(r.options.Threads)

// 开始扫描任务
processItem := func(k string) error {
......
r.process(k, &wg, r.hp, protocol, &r.scanopts, output)
......
}

if r.options.Stream {
// 流模式目标都在steamChan中
for item := range streamChan {
_ = processItem(item)
}
} else {
//非流模式默认从cachememory中取目标
r.hm.Scan(func(k, _ []byte) error {
return processItem(string(k))
})
}
......

​ 每个待扫描的任务都会调用 process 方法 ,该方法内部主要的方法调用有 targets() 、analyze() 、outputl()。

  • targets :确认需要扫描的目标集,例如待扫描的目标是 cidr,则返回 cidr 范围内的所有目标,若为单个地址则直接返回。
  • analyze :对目标发起请求,将结果集保存到 Result 结构体中返回。
  • output:根据 Result 结构体进行解析,反馈信息到终端。

​ 在调用 analyze 方法前,会对目标的协议和端口进行确认,可以直接拼接在目标地址中也可以通过命令参数指定。未指定协议时会先尝试 HTTPS,若请求失败才会继续尝试 HTTP,也可以通过参数 -nf 同时检查 HTTP/S,若命令行已指定协议和端口则会覆盖目标地址自带的配置,示例如下:

target.txt
├── https://www.baidu.com
├── qq.com
├── 1.1.1.1:8080
├── 2.2.2.2/24

httpx -l target.txt

通过命令行指定扫描协议和端口,命令行指定优先级 > 目标自拼接
httpx -u www.qq.com -title -p http:8080
httpx -u www.qq.com -title -nf 同时扫描http/s
httpx -l target.txt -title -p http\&https:8080 同时扫描http/s

3. Option 设计模式

​ httpx 大量使用 new(*option) *types 的方式来创建模块实例,在该方法内部对 *types 数据进行赋值然后返回实例,有默认值需要填充的字段采用变量 DefaultOptions 进行初始化。方法类似 Functional Options Pattern,不过 httpx 并没有使用闭包函数来修改 option 的值,我觉得可能因为在 newRunner 的时候根本就无法确认用户指定了哪些参数,参数个数不确定,并且用户配置的 Option 是通过 flag 进行解析的,对于用户没有输入的参数已经设置了默认值。在编写类似程序的时候也可以采用这种 option 的方式来创建实例。

type Runner struct {
options *Options
hp *httpx.HTTPX
scanopts scanOptions
asnClinet asn.ASNClient
......
}

func New(options *Options) (*Runner, error) {
runner := &Runner{
options: options,
asnClinet: asn.New(),
}
......
httpxOptions := httpx.DefaultOptions
httpxOptions.UnsafeURI = options.RequestURI
httpxOptions.CdnCheck = options.OutputCDN
......
runner.hp, err = httpx.New(&httpxOptions)
......
var scanopts scanOptions
scanopts.OutputTitle = options.ExtractTitle
scanopts.OutputIP = options.OutputIP
scanopts.OutputCName = options.OutputCName
......
runner.scanopts = scanopts
......
return runner, nil
}

4. 请求重试

httpx 在扫描时如果请求失败默认不重试,通过指定参数 -retries 5 即可设置重试次数。httpx 引入了项目 retryablehttp-go 来做请求重试,关于请求重试策略可参考文章:在 Go 中如何正确重试请求

5. hmap 使用

hmap 是混合内存/磁盘的 map,可帮助管理输入键值的存储,支持 BuntDB、BBoltDB、PogrebDB、LevelDB 进行数据存储,默认使用 LevelDB。

github 地址:hmap

​ httpx 用到 hmap 存储待扫描的目标,默认情况下不存储重复目标 (通过 Set() 会直接覆盖重复的目标),如果是流模式启动则可以带上参数 -skip-dedupe 直接将目标送入 channel ,不进行目标是否已存在的判断。

for item := range fchan {
// 1. SkipDedupe 默认为 false,即不检测重复目标
// 2. testAndSet 从 hmap 中寻找 key, 判断目标是否检测过, 检测过则返回 false
if r.options.SkipDedupe || r.testAndSet(item) {
out <- item
}

-----------------------------------------------------------------------------------

func (r *Runner) testAndSet(k string) bool {
// skip empty lines
k = strings.TrimSpace(k)
if k == "" {
return false
}
// 尝试 Get(k)
if r.seen(k) {
return false
}
// 尝试 Set(k)
r.setSeen(k)
return true
}

​ 同时 hmap 还支持设置键值的生命周期,如果使用 DefaultOptions 默认生命周期为 5 分钟 (ExpirationTime),每 1 分钟检查一次(JanitorTime)。

func (hm *HybridMap) Set(k string, v []byte) error {
var err error
......
switch hm.options.Type {
case Memory:
if hm.options.MemoryGuardForceDisk {
err = hm.diskmap.Set(k, v, hm.options.DiskExpirationTime)
} else {
hm.memorymap.Set(k, v)
}
......
return err
}

​ hmap 通过 Set 方法存储键值对的时候只能接收( key, value) ,所以在创建 hmap 时指定的生命周期将应用于所有的 key。如果存在一个特殊的 key 想实现生命周期自定义呢?

  • 方法 1: 由于 GO 不支持多态,可以新增一个 Set 方法专门用来接收 TTL 时间 SetWithTTL(k string,v interface, ttl time.Duration)
  • 方法 2: 使用上面提到的 Functional Options Pattern 实现。

​ 这里采用方法 2 实现的代码如下:

func (hm *HybridMap) Set(k string, v []byte,opts ...Option) error {
for _, opt := range opts{
opt(hm.options)
}
var err error
switch hm.options.Type {
case Memory:
if hm.options.MemoryGuardForceDisk {
err = hm.diskmap.Set(k, v, hm.options.DiskExpirationTime)
} else {
hm.memorymap.Set(k, v,hm.options.MemoryExpirationTime)
}
......
return err

-----------------------------------------------------------------------------------
type Option func(options *Options)

func WthTTL(duration time.Duration)Option{
return func(o *Options){
o.MemoryExpirationTime = duration
}
}

hmap 采用方法 2 修改后的示例如下:

func main() {
options := hybrid.DefaultOptions
options.MemoryExpirationTime = time.Second * 5
options.JanitorTime = time.Second * 1
hm, _ := hybrid.New(options)
defer hm.Close()
hm.Set("a", []byte("hello"))
hm.Set("b", []byte("world"), hybrid.WthTTL(time.Minute))
for i := 0; i < 3; i++ {
v, ok := hm.Get("a")
if ok {
fmt.Println(string(v))
} else {
fmt.Println("key 'a' no found")
}
time.Sleep(time.Second * 3)
}
hm.Scan(func(k, v []byte) error {
return show(k, v)
})
}
-----------------------------------------------------------------------------------
output:
hello
hello
key 'a' no found
key:b value:world

具有生命周期的键值对在项目中经常会用到,经测试发现 key 如果已经过期了,即时全量扫描检查时间还没到 (JanitorTime) 也无法通过 Get 获取到 value。但是在调用 hm.Scan() 方法时即时 key 过期了只要还没被全量扫描检查,则仍然会传入 Scan 方法内部执行。因为在 Get 时也会判断 key 是否已过期,但是全量扫描时会对过期的 key 进行删除。

func (c *CacheMemory) Get(k string) (interface{}, bool) {
......
item, found := c.Items[k]
......
if item.Expiration > 0 {
if time.Now().UnixNano() > item.Expiration {
c.mu.RUnlock()
return nil, false
-----------------------------------------------------------------------------------
func (c *CacheMemory) DeleteExpired() {
var evictedItems []keyAndValue
now := time.Now().UnixNano()
c.mu.Lock()
for k, v := range c.Items {
// "Inlining" of expired
if v.Expiration > 0 && now > v.Expiration {
ov, evicted := c.delete(k)
if evicted {
evictedItems = append(evictedItems, keyAndValue{k, ov})
}
}

httpx 还有很多子模块实现了很多针对性的功能,可以在后续项目中拿来做参考。

ProjectDiscovery 组织用 GO 造了很多好用的轮子,很多都可以在自己的项目中直接拿来使用。